/*
 * Created 15-Oct-2008
 * 
 * Copyright ThinkTank Maths Limited 2008
 * 
 * This file is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option)
 * any later version.
 *
 * This file is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this file. If not, see <http://www.gnu.org/licenses/>.
 */
package tms.utils;

import com.google.common.base.Preconditions;
import com.google.common.collect.Maps;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.io.ByteArrayInputStream;
import java.io.UnsupportedEncodingException;
import java.io.Closeable;
import java.io.Flushable;
import java.io.IOException;
import java.io.InputStream;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Iterator;
import java.util.Map;
import javax.xml.XMLConstants;
import javax.xml.datatype.DatatypeFactory;
import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.namespace.NamespaceContext;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Result;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

/**
 * A convenience class to simplify reading, querying, generating and validating XML. XML classes in Java
 * are notoriously verbose and rely on various factory and builder classes, with various thread
 * safety models that are poorly documented and lots and lots of checked exceptions that are often
 * implementation specific. The XPath classes are especially difficult to work with as they lack
 * fundamental support for namespace prefixes, have not been upgraded to use generics and are
 * not thread safe on any level (making pre-compilation of queries almost useless).
 * <p>
 * A typical XML-aware Java class file will have around 49 import statements, just to deal with the framework...
 * this class hides it such that the client code only needs to deal with the bare essentials (typically
 * 3 or 4 imports plus this file), resulting in much cleaner code.
 * <p>
 * This class attempts to hide as much IO as possible by
 * working with Strings (to allow swallowing of IO exceptions) and turns configuration-based
 * checked exceptions into runtime IllegalArgumentExceptions... shame on the initial designers!
 * Convenience methods are provided to allow one-line XPath queries, and all implementation specific
 * exceptions are turned into more fitting throwables... such as {@link ParseException} and
 * {@link XsdValidationException}.
 * <p>
 * This class is thread safe, so long as {@link #addXPathShorthand(String, String)} is only
 * called immediately after object construction.
 * 
 * @author Samuel Halliday
 */
public final class XMLHelper {

	/**
	 * Indicates a problem with validation against the supplied XSD.
	 * We use this in preference to {@code javax.xml.bind.ValidationException}
	 * in order to remain Java 5 compatible.
	 */
	public static class XsdValidationException extends Exception {

		/** serial version 1 */
		public static final long serialVersionUID = 1L;

		/** @param message */
		public XsdValidationException(String message) {
			super(message);
		}

		/** @param e */
		public XsdValidationException(Exception e) {
			super(e);
		}
	}

	/**
	 * This class makes up for Java's lack of support for XML Namespaces in
	 * {@link XPath}. It is confusing why XPath cannot simply use the Document's
	 * support for namespaces. The user may simply supply a map of prefixes to
	 * their expanded form. This class is thread safe once all prefixes have been
	 * supplied.
	 *
	 * @author Samuel Halliday
	 */
	public static class MappedNamespaceContext implements NamespaceContext {

		private final Map<String, String> prefixes = Maps.newHashMap();

		/**
		 * @param prefix
		 * @param namespaceURI
		 */
		public void addMapping(String prefix, String namespaceURI) {
			Preconditions.checkNotNull(prefix);
			Preconditions.checkNotNull(namespaceURI);
			prefixes.put(prefix, namespaceURI);
		}

		public String getNamespaceURI(String prefix) {
			Preconditions.checkArgument(prefix != null);
			if (prefixes.containsKey(prefix))
				return prefixes.get(prefix);
			return XMLConstants.NULL_NS_URI;
		}

		public String getPrefix(String namespaceURI) {
			throw new UnsupportedOperationException();
		}

		public Iterator<?> getPrefixes(String namespaceURI) {
			throw new UnsupportedOperationException();
		}
	}
	private final DocumentBuilder docBuilder;
	private final DatatypeFactory dtFactory;
	private final Schema schema;
	private final TransformerFactory transFactory = TransformerFactory.newInstance();
	private final XPathFactory xPathFactory = XPathFactory.newInstance();
	private final MappedNamespaceContext nsContext = new MappedNamespaceContext();
	// pool of XPath objects, for internal use only
	private final ThreadLocal<XPath> xPathPool = new ThreadLocal<XPath>() {

		@Override
		protected XPath initialValue() {
			return newXPath();
		}
	};

	// stores a thread local map from XPath expressions to complied queries, only to be used internally.
	private final ThreadLocal<Map<String, XPathExpression>> threadLocalCompiled =
		new ThreadLocal<Map<String, XPathExpression>>() {

			@Override
			protected Map<String, XPathExpression> initialValue() {
				return Maps.newHashMap();
			}
		};

	/**
	 * @param schemaText which must be the contents of a valid XSD file, or null if there are no external
	 * schemas to load.
	 * @see <a href="http://www.w3.org/XML/Schema">W3C XML Schema</a>
	 * @throws IllegalArgumentException if the schemaText is not valid.
	 */
	public XMLHelper(String schemaText) {
		try {
			DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
			docFactory.setNamespaceAware(true);
			docBuilder = docFactory.newDocumentBuilder();
			dtFactory = DatatypeFactory.newInstance();
			SchemaFactory schemaFactory = SchemaFactory.newInstance(
				XMLConstants.W3C_XML_SCHEMA_NS_URI);
			if (schemaText == null)
				schema = schemaFactory.newSchema();
			else {
				Document schemaDoc = getDocument(schemaText);
				Source schemaSource = new DOMSource(schemaDoc);
				schema = schemaFactory.newSchema(schemaSource);
			}
		} catch (Exception e) {
			if (e instanceof SAXException)
				throw new IllegalArgumentException("schemaText not valid: " + e.getMessage());
			throw new GuruMeditationFailure(e);
		}
	}

	/**
	 * Convenience for {@link XmlHelper(String, Map)} with no external XSD files and no support for
	 * namespaces in XPath queries.
	 */
	public XMLHelper() {
		this(null);
	}

	/**
	 * Adds a mapping from a prefix (e.g. "C") to an expanded namespace
	 * (e.g. "urn:ietf:params:xml:ns:caldav") that allows the prefixes to be used in XPath queries.
	 * Note that this method need only be called once per mapping and should only be used immediately
	 * after construction of this, in order to avoid any concurrency problems.
	 * 
	 * @param prefix
	 * @param nameSpace
	 */
	public void addXPathShorthand(String prefix, String nameSpace) {
		Preconditions.checkNotNull(prefix);
		Preconditions.checkNotNull(nameSpace);
		nsContext.addMapping(prefix, nameSpace);
	}

	/**
	 * @return a new document (namespace aware)
	 */
	public Document newDocument() {
		return docBuilder.newDocument();
	}

	/**
	 * @param xml
	 * @return a namespace aware document, but has not been validated against the schema
	 * @throws ParseException if there was a problem parsing the input
	 */
	public Document getDocument(String xml) throws ParseException {
		Preconditions.checkNotNull(xml);
		InputStream stream = null;
		try {
			stream = stringToStream(xml);
			return docBuilder.parse(stream);
		} catch (SAXException e) {
			throw new ParseException(xml, -1);
		} catch (IOException ex) {
			// shouldn't be possible as we are using strings, not IO
			throw new GuruMeditationFailure(ex);
		} finally {
			close(stream);
		}
	}

	/**
	 * @return a datatype factory, for ensuring that data entry is valid (e.g. datetime stamps)
	 */
	public DatatypeFactory getDatatypeFactory() {
		return dtFactory;
	}

	/**
	 * @return
	 */
	public Schema getSchema() {
		return schema;
	}

	/**
	 * @return
	 */
	public XPath newXPath() {
		XPath xPath = xPathFactory.newXPath();
		xPath.setNamespaceContext(nsContext);
		return xPath;
	}

	/**
	 * @return
	 */
	public Validator newValidator() {
		return schema.newValidator();
	}

	/**
	 * @return a transformer with pretty indenting
	 */
	public Transformer newTransformer() {
		try {
			Transformer transformer = transFactory.newTransformer();
			transformer.setOutputProperty(OutputKeys.INDENT, "yes");
			return transformer;
		} catch (TransformerConfigurationException ex) {
			// stupid checked exception
			throw new GuruMeditationFailure(ex);
		}
	}

	/**
	 * Perform basic validation (i.e. no special error handler) on the document
	 * @param source
	 * @throws ValidationException if the validation fails.
	 */
	public void validate(Source source) throws XsdValidationException {
		Preconditions.checkNotNull(source);
		Validator validator = schema.newValidator();
		try {
			validator.validate(source);
		} catch (SAXException e) {
			throw new XsdValidationException(e);
		} catch (IOException ex) {
			// shouldn't be possible with DOM
			throw new GuruMeditationFailure(ex);
		}
	}

	/**
	 * Convenience class for {@link #validate(javax.xml.transform.Source)}, which converts the node
	 * into a Source object.
	 * 
	 * @param node
	 * @throws ValidationException
	 */
	public void validate(Node node) throws XsdValidationException {
		Source source = new DOMSource(node);
		validate(source);
	}

	/**
	 * @param date
	 * @return the contents for a fully validated XML dateTime element or attribute, e.g.
	 * {@code 2002-10-10T12:00:00-05:00}. Note it is not possible to achieve this with
	 * {@link java.text.SimpleDateFormat}.
	 * @see <a href="http://www.w3.org/TR/xmlschema-2/#dateTime">dateTime</a>
	 */
	public String toXmlDateTime(Date date) {
		Preconditions.checkNotNull(date);
		GregorianCalendar cal = new GregorianCalendar();
		cal.setTime(date);
		XMLGregorianCalendar xmlCal = dtFactory.newXMLGregorianCalendar(cal);
		return xmlCal.toXMLFormat();
	}

	/**
	 * @param source
	 * @return a String representation of the given source, i.e. create the contents for an XML file.
	 */
	public String toXmlString(Source source) {
		Preconditions.checkNotNull(source);
		StringWriter writer = new StringWriter();
		Result result = new StreamResult(writer);
		Transformer transformer = newTransformer();
		try {
			transformer.transform(source, result);
		} catch (TransformerException ex) {
			// when this happens is ill defined, so why is it checked! Recovery is impossible
			throw new GuruMeditationFailure(ex);
		}
		return writer.toString();
	}

	/**
	 * Convenience class for {@link #toXmlString(javax.xml.transform.Source)}, which converts the node
	 * into a Source object.
	 * 
	 * @param node
	 * @return
	 */
	public String toXmlString(Node node) {
		Preconditions.checkNotNull(node);
		Source source = new DOMSource(node);
		return toXmlString(source);
	}

	/**
	 * Convenience method wrapping {@link XPath#evaluate(String, Object)} and
	 * converting checked exceptions into runtime.
	 * 
	 * @param expr
	 * @param node
	 * @param threadCompile set to true if this query is eligible for compiling for this thread
	 * (note that XPath queries are not thread safe, therefore must be thread local). There is a cost
	 * associated to doing this, so it should only be used for queries that appear in a for loop in
	 * order to obtain a computational advantage. Profiling has shown that the for loop has to be in
	 * the region of 10,000 iterations in order to abtain any advantage.
	 * @return
	 * @throws IllegalArgumentException if the expression is invalid
	 */
	public String xPathString(String expr, Node node, boolean threadCompile) {
		Preconditions.checkNotNull(expr);
		Preconditions.checkNotNull(node);
		try {
			if (threadCompile) {
				XPathExpression compiled = compiledThreadLocal(expr);
				return compiled.evaluate(node);
			} else {
				XPath xPath = xPathPool.get();
				return xPath.evaluate(expr, node);
			}
		} catch (XPathExpressionException ex) {
			throw new IllegalArgumentException(ex);
		}
	}

	/**
	 * Convenience for {@link #xPathString(String, Node, boolean)} with {@code threadCompile = false}.
	 * 
	 * @param expr
	 * @param node
	 * @return
	 * @see #xPathString(String, Node, boolean)
	 */
	public String xPathString(String expr, Node node) {
		return xPathString(expr, node, false);
	}

	/**
	 * Convenience method wrapping {@link XPath#evaluate(String, InputSource, QName)} and
	 * converting checked exceptions into runtime, also provides Java 5 generics support.
	 *
	 * @param expr
	 * @param node
	 * @param threadCompile set to true if this query is eligible for compiling for this thread
	 * (note that XPath queries are not thread safe, therefore must be thread local). There is a cost
	 * associated to doing this, so it should only be used for queries that appear in a for loop in
	 * order to obtain a computational advantage. Profiling has shown that the for loop has to be in
	 * the region of 10,000 iterations in order to abtain any advantage.
	 * @return
	 * @throws IllegalArgumentException if the expression is invalid
	 */
	public Iterable<Node> xPathNodes(String expr, Node node, boolean threadCompile) {
		Preconditions.checkNotNull(expr);
		Preconditions.checkNotNull(node);
		NodeList responses;
		try {
			if (threadCompile) {
				XPathExpression compiled = compiledThreadLocal(expr);
				responses = (NodeList) compiled.evaluate(node, XPathConstants.NODESET);
			} else {
				XPath xPath = xPathPool.get();
				responses = (NodeList) xPath.evaluate(expr, node, XPathConstants.NODESET);
			}
		} catch (XPathExpressionException ex) {
			throw new IllegalArgumentException(ex);
		}
		Collection<Node> nodes = new ArrayList<Node>(responses.getLength());
		for (int i = 0; i < responses.getLength(); i++) {
			nodes.add(responses.item(i));
		}
		return nodes;
	}

	/**
	 * Convenience for {@link #xPathNodes(String, Node, boolean)} with {@code threadCompile = false}.
	 * 
	 * @param expr
	 * @param node
	 * @return
	 * @see #xPathNodes(String, Node, boolean)
	 */
	public Iterable<Node> xPathNodes(String expr, Node node) {
		return xPathNodes(expr, node, false);
	}

	// internal convenience method for creating thread local compiled XPath queries that should
	// never be released externally from this class
	private XPathExpression compiledThreadLocal(String expr) {
		Map<String, XPathExpression> allCompiled = threadLocalCompiled.get();
		XPathExpression compiled = allCompiled.get(expr);
		if (compiled != null)
			return compiled;
		XPath xPath = xPathPool.get();
		try {
			compiled = xPath.compile(expr);
		} catch (XPathExpressionException ex) {
			throw new IllegalArgumentException(ex);
		}
		allCompiled.put(expr, compiled);
		return compiled;
	}

	/**
	 * Converts a stream to a String, converting end-of-line markers into "{@code \n}".
	 *
	 * @param stream
	 * @return
	 * @throws IOException
	 */
	public static String streamToString(InputStream stream) throws IOException {
	    Preconditions.checkNotNull(stream);
	    try {
	        InputStreamReader reader = new InputStreamReader(stream);
	        BufferedReader buffered = new BufferedReader(reader);
	        StringBuilder builder = new StringBuilder();
	        String line;
	        while ((line = buffered.readLine()) != null) {
	            builder.append(line);
	            builder.append("\n");
	        }
	        return builder.toString();
	    } finally {
	        close(stream);
	    }
	}

	// you might have your own version of these next 2 methods
	private InputStream stringToStream(String string) {
		Preconditions.checkNotNull(string);
		try {
			return new ByteArrayInputStream(string.getBytes("UTF-8"));
		} catch (UnsupportedEncodingException ex) {
			// should never happen
			throw new GuruMeditationFailure(ex);
		}
	}
	private static void close(Closeable input) {
		if (input == null)
			return;
		if (input instanceof Flushable)
			try {
				((Flushable) input).flush();
			} catch (IOException e) {
				// log.warning("failed to flush Flushable " + input);
			}
		try {
			input.close();
		} catch (IOException e) {
			// log.warning("failed to close Closeable " + input);
		}
	}
}
